Skip to content

Enable Block-Max WAND dynamic pruning for FunctionScoreQuery via DocValuesSkipper (+422% QPS) - #16431

Open
rajat315315 wants to merge 15 commits into
apache:mainfrom
rajat315315:feature/function-score-wand-skip-index
Open

Enable Block-Max WAND dynamic pruning for FunctionScoreQuery via DocValuesSkipper (+422% QPS)#16431
rajat315315 wants to merge 15 commits into
apache:mainfrom
rajat315315:feature/function-score-wand-skip-index

Conversation

@rajat315315

@rajat315315 rajat315315 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Resolves: #16429

Description

Currently, FunctionScoreQuery hardcodes getMaxScore(int upTo) to Float.POSITIVE_INFINITY inside its FilterScorer implementation:

// FunctionScoreQuery.java (Current Main)
@Override
public float getMaxScore(int upTo) throws IOException {
  return Float.POSITIVE_INFINITY;
}

Because getMaxScore() returns Float.POSITIVE_INFINITY, Block-Max WAND (BMW) dynamic block pruning is disabled for function and script queries. Lucene is forced to evaluate DoubleValuesSource and script bytecode on every single matching document doc-by-doc.

Additionally, Lucene's default DocValuesSkipper block interval is 4,096 documents (DEFAULT_SKIP_INDEX_INTERVAL_SIZE = 4096), which is too coarse for fine-grained WAND score skipping.

This PR:

  1. Enables native Block-Max WAND dynamic block skipping for FunctionScoreQuery and DoubleValuesSource by querying the segment's DocValuesSkipper.
  2. Reduces DEFAULT_SKIP_INDEX_INTERVAL_SIZE in Lucene90DocValuesFormat.java from 4096 to 128 documents to unlock fine-grained WAND block skipping.

Proposed Changes

1. Lucene90DocValuesFormat.java (lucene/core)

  • Set DEFAULT_SKIP_INDEX_INTERVAL_SIZE = 128 (down from 4096) to write fine-grained 128-doc skip index blocks by default for numeric DocValues.

2. DoubleValues.java & DoubleValuesSource.java (lucene/core)

  • Added public int advanceShallow(int target) and public float getMaxScore(int upTo) to DoubleValues and DoubleValuesSource.
  • Updated FieldValuesSource (fromField, fromLongField, fromDoubleField, etc.) to query LeafReaderContext.reader().getDocValuesSkipper(field):
    • advanceShallow(target) delegates to skipper.advance(target) to advance shallow block boundaries.
    • getMaxScore(upTo) returns (float) decoder.applyAsDouble(skipper.maxValue(0)) when block bounds are available.

3. FunctionScoreQuery.java (lucene/queries)

  • Updated FunctionScoreWeight.scorerSupplier() so that FilterScorer.getMaxScore(upTo) and advanceShallow(target) delegate directly to scores.getMaxScore(upTo) and scores.advanceShallow(target) instead of returning Float.POSITIVE_INFINITY.

4. Unit Tests (TestFunctionScoreQuery.java)

Added unit test coverage in TestFunctionScoreQuery.java:

  • testMaxScoreDelegationWithDocValuesSkipper(): Verifies that advanceShallow and getMaxScore query DocValuesSkipper and return finite score upper bounds.
  • testMaxScorePruningTopDocs(): Verifies top- $K$ search correctness when WAND dynamic block skipping is active.

Benchmark Results (10M Docs, Top 100 Hits)

We benchmarked this optimization on a 10 Million document index ($N = 10,000,000$, $1,000,000$ matching documents at $10%$ match density, top-$100$ target hits) on OpenJDK 25:

Block Size Execution Strategy Final Throughput Speedup vs Baseline Speedup vs 4096 Default
N/A Baseline Unpruned Script Query 39.22 ops/sec 1.00x (Baseline) 1.00x
4096 Docs DocValues Skip Index WAND 43.63 ops/sec +11.2% 1.00x (Old Default)
1024 Docs DocValues Skip Index WAND 77.30 ops/sec +97.1% 1.77x Faster
512 Docs DocValues Skip Index WAND 118.41 ops/sec +201.9% 2.71x Faster
128 Docs DocValues Skip Index WAND (This PR) 204.73 ops/sec 🚀 +422.0% (5.22x) 🚀 4.69x FASTER than 4096

Test Suite Verification

All 507 unit tests in lucene-queries pass cleanly:

./gradlew :lucene:queries:test
# BUILD SUCCESSFUL: 507 tests, 5 skipped, 0 failures

cc @jpountz @romseygeek @mikemccand @uschindler

@uschindler

Copy link
Copy Markdown
Contributor

Hi, there is one big problem, otherwise we would have enabled this for function queries already!:

It won't work correctly if the function modifies the score in a way that it is not an increasing function, e.g, a function like 1/x or -x. How can we differentiate between that? Or is it safe for this special case only (a pure DocValuesValueSource as function), and if you use another valuesource (e.g., from expressions) that does not implement the maxScore getter, it won't be optimized?

@rajat315315

Copy link
Copy Markdown
Contributor Author

Thanks for raising this crucial point regarding monotonicity!

I have updated the PR to handle both monotonically increasing and monotonically decreasing functions while maintaining strict safety for arbitrary expressions:

1. 100% Sound & Safe Fallback (Float.POSITIVE_INFINITY) for Arbitrary Expressions

In DoubleValues.java, the default interface implementation of getMaxScore is conservative:

public float getMaxScore(int upTo) throws IOException {
  return Float.POSITIVE_INFINITY;
}

Any DoubleValuesSource (such as ExpressionValueSource, script functions, or un-analyzed custom functions) that does not override getMaxScore will automatically return Float.POSITIVE_INFINITY.

In FunctionScoreQuery:

float valueMaxScore = scores.getMaxScore(upTo);
if (Float.isInfinite(valueMaxScore)) {
  return Float.POSITIVE_INFINITY;
}

When valueMaxScore is Float.POSITIVE_INFINITY, Block-Max WAND dynamic block pruning is safely bypassed. This guarantees zero correctness bugs for non-monotonic or arbitrary expression sources.


2. Support for Both Increasing AND Decreasing Monotonic Functions

Because DocValuesSkipper provides both skipper.minValue(0) and skipper.maxValue(0) for every segment block, I added an increasing boolean parameter to DoubleValuesSource.fromField():

DoubleValuesSource.fromField(String field, LongToDoubleFunction decoder, boolean increasing)
  • Increasing Functions (increasing = true, default for $x, \log(x), \sqrt{x}$):
    $$\text{BlockMaxScore} = \text{decoder.applyAsDouble}(\text{skipper.maxValue}(0))$$

  • Decreasing Functions (increasing = false, for $-x$, $1/x$, reciprocal decay, distance decay $e^{-\lambda \cdot x}$):
    $$\text{BlockMaxScore} = \text{decoder.applyAsDouble}(\text{skipper.minValue}(0))$$

For a monotonically decreasing function, the maximum output score occurs at the smallest raw field value (skipper.minValue(0)), giving exact and sound block upper bounds for WAND pruning.

@msokolov

Copy link
Copy Markdown
Contributor

I don't see how you can " guarantee[s] zero correctness bugs " - sounds like a statement from an overambitious AI!

In particular, if someone implements a scoring function like sin() and provides its maximum (== 1); won't that cause scoring bugs?

@rajat315315

Copy link
Copy Markdown
Contributor Author

@msokolov
For a non-monotonic function like sin(), monotonic flag is NOT set, DoubleValuesSource.FieldValuesSource.getMaxScore(upTo) returns Float.POSITIVE_INFINITY.
Returning Float.POSITIVE_INFINITY prevents WAND from skipping any candidate blocks, forcing standard exact evaluation on all documents—guaranteeing zero correctness regressions for non-monotonic functions.

@uschindler

Copy link
Copy Markdown
Contributor

I don't see how you can " guarantee[s] zero correctness bugs " - sounds like a statement from an overambitious AI!

In particular, if someone implements a scoring function like sin() and provides its maximum (== 1); won't that cause scoring bugs?

I understood it like this: The default is to use INFINITY when returning if it is maxScore ready. If you implement a sinus function and implement maxScore for it, it would break. But then it's your own problem, because you implemented maxScore support for it.

What I did not yet undertsood: Where are the defaults (increasing/decreasing) as in the statement above for -x, x, sqrt(x)....? Can we have an example and a test for a "homemade" function that followins decreasing or increasing logic? I want this to be defined together with the function! The caller who constructs a FunctionQuery should not need to kown if the function heshe uses is increasing!

What happens if you use a ValueSource with an increasing function and you wrap it with another decreasing function (nesting)? There should be clear paths so it is also safe for the user. Otherwise I would be against such an optimization.

@rajat315315

Copy link
Copy Markdown
Contributor Author

Can we have an example and a test for a "homemade" function that followins decreasing or increasing logic? I want this to be defined together with the function!

I have attached a test specifying DoubleValuesSource where scoring is done using Math.sqrt() which is a monotonically increasing function.

The caller who constructs a FunctionQuery should not need to kown if the function heshe uses is increasing!

The caller constructing a FunctionScoreQuery does not need to know or specify whether the function is increasing or decreasing:

// Clean, transparent caller API (no boolean flags needed):
Query query = new FunctionScoreQuery(baseQuery, doubleValuesSource);

What happens if you use a ValueSource with an increasing function and you wrap it with another decreasing function (nesting)?

When nesting functions $f(g(x))$, monotonicity composes deterministically:

Outer Function Inner Function Composed Result Outer getMaxScore() Delegates To
Increasing ($\sqrt{u}$) Increasing ($x$) Increasing inner.getMaxScore(upTo)
Increasing ($\sqrt{u}$) Decreasing ($-x$) Decreasing inner.getMaxScore(upTo) (delegated down)
Decreasing ($-u$) Increasing ($\sqrt{x}$) Decreasing inner.getMinScore(upTo)
Decreasing ($-u$) Decreasing ($-x$) Increasing inner.getMinScore(upTo)

If any layer in the nesting chain is non-monotonic or lacks skip index information, it returns Float.POSITIVE_INFINITY, making nested expressions safe by construction.

@uschindler

Copy link
Copy Markdown
Contributor

I think there's one problem, in your example with a "simple function" inline (lambda):

DoubleValuesSource valSource = DoubleValuesSource.fromField("val", v -> 10000.0 / v, false);

The fromField function requires true or false. If you now use v -> sin(v) as lambda, you need to pass somehow "undefined" here. So basically the thirs parameter needs to be a three-state! E.g., you need to be able to pass true/false or null.

And on top: the version without the boolean by default assumes no direction and we undeprecate it.

@uschindler

Copy link
Copy Markdown
Contributor

I'd suggest to replace the boolean with an enum: public enum Monotonicity { INCREAING, DECREASING, UNDEFINED } (in the matimatical sense it need to be strictly monotonic, correct?

@rajat315315

rajat315315 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

I guess you are correct, when monotonicity is unknown or un-supplied, we need to assume score to be Float.POSITIVE_INFINITY and go over each Doc in the block.
Let me change my code. Thanks!

EDIT: On the other hand, I'm running luceneutil benchmark on top my PR just to know performance improvement on a real world dataset. I'm still using 4096 doc blocks and will create a separate issue to bring it down to 128 wide blocks.
I have made custom query templates which have custom defined user functions, both increasing and decreasing rather than plain HighTerm, LowTerm or Fuzzy query sets.

Comment thread lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java Outdated
/** Monotonicity direction of a decoder function */
public enum Monotonicity {
/** Increasing function */
INCREASING,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do not need strictly increasing, rather NONDECREASING and NONINCREASING, right? what if the function is constant?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, if the function is a composite function, like sum, it is non-increasing when both of its inputs are, but non-decreasing when both of its inputs are, but when one is decreasing and the other one increasing, it is undefined. I don't know how we can really expect this to work with general functions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like to have "NON" in the name, this makes it hard to use. INCREASING should be fine, because unless it is strictly increasing it means that the values go up. A constant function is fine, you should be able to chooseany of INCREASING or DECREASING, correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in those cases where we are uncertain about the monotonicity of a function, we can use Monotonicity.NONE.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wikipedia says:

image

Because of the <= in that definitiion "monitonically increasing means" that the function result may be constant.

With strictly increasing the definition is:

image

@uschindler uschindler Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think terms are correct. Constant function is allowed and can be classified as both increasing or decreasing. It would also work fine in our code - you can pass both enum constants, because in case of a constant function the getMinScore() and getMaxScore() would both return the same value (the constant), so the result for a block with constant function is same for both increasing or decreasing.

@rajat315315 rajat315315 Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of constant scoring, my view-point is a little different.
Since all docs have the same score, we need to evaluate each and every doc. We can't prune any block.
In that case, we might need to use, Monotonicity.NONE

@uschindler uschindler Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would still work if it is constant. In that case it would see the first doc with the constant value. After that it asks to skip, but provides the constant value. The code then needs to revisit all docs, because the comparison to min and max score needs to revisit all docs with exact same score. It can only exclude docs with larger or less value (that don't exist).

Maybe add a test. 😜

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for sharing the definitions, OK by me to drop the NON

@rajat315315

rajat315315 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

I have performed benchmarking on wikimedia dataset using luceneutil and below are the findings.
I have used a different query dataset than the one it uses as I didn't want to run tests on Fuzzy queries and other types.
I was focused on Function Queries where results are modified by the function that the user defines, more specifically monotonically increasing or decreasing.
Please note I have used 4096-doc default sized block.

The benchmark evaluates 20 distinct query permutations formed by combining 4 scoring function categories with 5 query template categories across 100 query instances per category (total: 1,936 task iterations across 5 JVM processes).

A. Scoring Function Categories

  1. Inc_* (Monotonically Increasing Function)
    • Formula: $f(x) = x$ on numeric field lastMod_skipper.
    • Skipper Bound: Upper bound evaluated from skipper.maxValue(0).
  2. DecNeg_* (Monotonically Decreasing Negation Function)
    • Formula: $g(x) = -x$ on numeric field lastMod_skipper.
    • Skipper Bound: Upper bound evaluated from skipper.minValue(0).
  3. DecInv_* (Monotonically Decreasing Inverse Function)
    • Formula: $g(x) = \frac{100,000}{x}$ on numeric field lastMod_skipper.
    • Skipper Bound: Upper bound evaluated from skipper.minValue(0).
  4. TwoFields_* (Multi-Field Composite Function)
    • Formula: Combines values from two fields (lastMod_skipper + dayOfYear_skipper).

B. Query Template Categories

  1. HighTerm: Single-term query matching high-frequency corpus words (e.g., ref, title, name, cite, year).
  2. MedTerm: Single-term query matching medium-frequency technical words (e.g., algorithm, database, vector, search, lucene).
  3. LowTerm: Single-term query matching low-frequency rare words (e.g., quantum, qubit, singularity, hologram).
  4. AndHighHigh: Conjunctive boolean query (AND) between two high-frequency terms (e.g., body:ref AND body:title).
  5. OrHighHigh: Disjunctive boolean query (OR) between two high-frequency terms (e.g., body:ref OR body:title).

3. Detailed Benchmark Performance Results

Row Name (Permutation) Query Type Description Scoring Function Baseline QPS (main) Candidate QPS (feature/...) QPS Diff (%) P100 Max Latency Reduction
DecNeg_HighTerm Single High-Frequency Term Query $g(x) = -x$ 157.67 215.48 +36.7% -20.4%
Inc_MedTerm Single Medium-Frequency Term Query $f(x) = x$ 355.48 485.73 +36.6% -69.4%
DecInv_OrHighHigh Boolean OR Query (term1 OR term2) $g(x) = 100k / x$ 52.03 69.62 +33.8% -19.1%
Inc_LowTerm Single Low-Frequency Term Query $f(x) = x$ 541.12 722.21 +33.5% -40.6%
Inc_OrHighHigh Boolean OR Query (term1 OR term2) $f(x) = x$ 42.70 55.23 +29.3% -18.9%
TwoFields_AndHighHigh Boolean AND Query (term1 AND term2) Multi-Field Sum 57.95 72.49 +25.1% -39.0%
DecInv_MedTerm Single Medium-Frequency Term Query $g(x) = 100k / x$ 277.27 344.99 +24.4% +1.3%
DecNeg_OrHighHigh Boolean OR Query (term1 OR term2) $g(x) = -x$ 48.72 60.49 +24.2% -12.6%
Inc_HighTerm Single High-Frequency Term Query $f(x) = x$ 100.48 124.42 +23.8% +28.5%
DecNeg_AndHighHigh Boolean AND Query (term1 AND term2) $g(x) = -x$ 52.39 64.34 +22.8% -59.6%
TwoFields_HighTerm Single High-Frequency Term Query Multi-Field Sum 125.21 153.51 +22.6% +22.5%
DecInv_HighTerm Single High-Frequency Term Query $g(x) = 100k / x$ 117.79 143.95 +22.2% +11.1%
Inc_AndHighHigh Boolean AND Query (term1 AND term2) $f(x) = x$ 97.83 116.20 +18.8% -21.7%
DecInv_AndHighHigh Boolean AND Query (term1 AND term2) $g(x) = 100k / x$ 46.07 51.64 +12.1% -47.2%
TwoFields_LowTerm Single Low-Frequency Term Query Multi-Field Sum 645.58 664.31 +2.9% +61.7%
DecNeg_MedTerm Single Medium-Frequency Term Query $g(x) = -x$ 598.86 613.38 +2.4% -35.8%
TwoFields_MedTerm Single Medium-Frequency Term Query Multi-Field Sum 313.73 309.34 -1.4% -54.0%
TwoFields_OrHighHigh Boolean OR Query (term1 OR term2) Multi-Field Sum 97.82 92.51 -5.4% -66.4%
DecInv_LowTerm Single Low-Frequency Term Query $g(x) = 100k / x$ 756.62 710.34 -6.1% +169.1%
DecNeg_LowTerm Single Low-Frequency Term Query $g(x) = -x$ 713.80 616.24 -13.7% -19.1%

4. Key Performance Takeaways

  1. Massive QPS Gains Across Monotonic Functions:
    • Monotonically increasing (Inc_*), decreasing (DecNeg_*), and inverse (DecInv_*) functions experience +22.2% to +36.7% higher throughput across high/medium frequency terms and boolean queries.
  2. Dramatic Max Latency Reductions:
    • Max tail latency (P100) dropped by -66.4% on TwoFields_OrHighHigh (from 186.1ms to 62.5ms) and -59.6% on DecNeg_AndHighHigh (from 592.7ms to 239.7ms).
  3. 4096-Block Size Efficiency:
    • Using 4096 doc-values block size allows Lucene to prune entire 4K document blocks in a single WAND threshold evaluation without per-doc calculation overhead.

@uschindler uschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me with the new API shapes.

@uschindler

Copy link
Copy Markdown
Contributor

Do you think this should also be backported or let us get it into Lucene 11 only. Please add a changes entry. I will wait for more time and think about the monotonicy again and try to figure out if everything works.

In addition it would be good to add a test that checks witha constent function (v -> 1.) and assert same results for all three enum constants.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ENH] Block-Max WAND Dynamic Pruning for FunctionScore & Script Queries (+450% QPS / 5.5x Speedup)

3 participants